Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

shell: Even more types #21425

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open

Conversation

mvollmer
Copy link
Member

No description provided.

A manifest is now just a JsonObject, and users are expected to
type-cast it (or parts) of it to more concrete types, like the new
ShellManifest and ManifestParentSection.
The cockpit.spawn function takes only two arguments (unlike
cockpit.script).

Location.reload() doesn't take any arguments, the "force_reload"
argument is a Firefox extension.
@mvollmer
Copy link
Member Author

This time I tried out fully typing two largish files without any intermediate "relaxed" steps. It's fun but also quite intense.

if (begin < 0)
begin = all.length - 1;
all[begin].focus();
} else {
let i = all.findIndex(item => item === cur);
i += step;
if (i < 0 || i >= all.length)
document.querySelector("#" + sel + " .pf-v5-c-text-input-group__text-input").focus();
document.querySelector<HTMLElement>("#" + sel + " .pf-v5-c-text-input-group__text-input")?.focus();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

Comment on lines +207 to +208
if (g.action)
this.props.jump(g.action.target);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 2 added lines are not executed by any test.

@@ -366,6 +412,7 @@
target: {
host: current_machine.address,
path: current_machine_manifest_items.items.apps.path,
hash: "/",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a test for this. This is about the "Edit" button in the "Applications" section. It only appears when at least one applications is installed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also smells like a bug fix which would be better to split out into a new commit.

}

const manifest = cockpit.manifests.shell || { };
const manifest = (cockpit.manifests.shell || { }) as ShellManifest;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

@@ -140,8 +144,9 @@
<MenuList>
{
(() => {
const filteredLocales = Object.keys(manifest.locales || {})
.filter(key => !searchInput || manifest.locales[key].toLowerCase().includes(searchInput.toString().toLowerCase()));
const locales = manifest.locales || {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

{cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)}
</DropdownItem>);

const shell_manifest = (cockpit.manifests.shell || {}) as ShellManifest;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

// global documentation for cockpit as a whole
(cockpit.manifests.shell?.docs ?? []).forEach(doc => {
(shell_manifest.docs ?? []).forEach(doc => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This added line is not executed by any test.

@mvollmer mvollmer marked this pull request as ready for review December 12, 2024 13:14
@mvollmer mvollmer mentioned this pull request Dec 12, 2024
1 task
@@ -91,6 +91,16 @@ export interface ManifestItem {
keywords: ManifestKeyword[];
}

export interface ManifestParentSection {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... we could make some functions like manifest_parent_section(m: Manifest): ManifestParentSection which would ide all the "unsafe" JSON manipulations.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole Manifest stuff should go to its own file, actually.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like, helpers which do type validation? what would they do on errors then?

This feels simultaneously too strict (hard to handle errors at that level, as these are effectively user-provided files) and too specific (we parse JSON everywhere, and should generalize the mechanics).

Are you aware of the bridge's jsonutil? That may be a more adequate way to parse JSON with strict typing, and the API would then also include default values.

Copy link
Member

@martinpitt martinpitt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Nice that this found some bugs! Most of the comments are nice, bikeshedding, praise, and questions. But I have serious concerns about using as on externally read data, that feels just wrong and dangerous to me.

@@ -91,6 +91,16 @@ export interface ManifestItem {
keywords: ManifestKeyword[];
}

export interface ManifestParentSection {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like, helpers which do type validation? what would they do on errors then?

This feels simultaneously too strict (hard to handle errors at that level, as these are effectively user-provided files) and too specific (we parse JSON everywhere, and should generalize the mechanics).

Are you aware of the bridge's jsonutil? That may be a more adequate way to parse JSON with strict typing, and the API would then also include default values.

pkg/shell/util.tsx Show resolved Hide resolved
Comment on lines +114 to +115
const manifest_section = (manifest[section] || {}) as ManifestSection;
Object.entries(manifest_section).forEach(([prop, info]) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast is weird and IMHO detrimental. This isn't a case of "ts can't figure it out", it's literally "this could be anything as it's an user-provided (or at least user-customizable) external file on disk". So even after the as you can in no way be sure that you actually have a ManifestSection object, and the type checker will hide errors such as "component is actually an int". I.e. you'll get runtime errors because you forgot to check the type. That's the opposite of what typing should achieve.

That's why I think for this case something like json_get_object(manifest, section, {}) would be much cleaner, more robust, and also more general here. The original code was actually fine already, but the helper functions could produce proper console warnings that help admins to figure out why their manifest customizations are being ignored.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cast is weird and IMHO detrimental.

Yes... it is keeping the status quo, but we should try to do better eventually, agreed.

I.e. you'll get runtime errors because you forgot to check the type. That's the opposite of what typing should achieve.

Hmm, I would call this input validation rather than static typing. Currently, we validate manifest input by just pretending it's all valid and let the shell crash when it isn't. (And we have been fine with that since forever.) Static typing makes this obvious, but by itself is not enough to improve it.

We would have to write new code to do real input validation. I was hoping there is a way to get TypeScript to do that. Here are some good pointers: https://stackoverflow.com/questions/33800497/check-if-an-object-implements-an-interface-at-runtime-with-typescript

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I would call this input validation rather than static typing

Yes, exactly. And input validation is a runtime thing, the type checker can't help with that. We either need some "JSON schema validation" thing, or just pick out the values individually with the expected type and default (which I personally favor -- it's explicit, tsc can help us get it right, and we don't reject manifests with wrong keys wholesale, which would be a behaviour changes).

Comment on lines -175 to +190
if (comp && comp.parent && comp.parent.component)
component = comp.parent.component as string;
if (comp && comp.parent) {
const parent = comp.parent as ManifestParentSection;
if (parent.component)
component = parent.component;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the same spirit as above, I think componennt = comp?.parent?.component would be both cleaner and more robust. Alternatively, wrap this into some json_get_object() helper with warning messages -- i.e. "nonexisting" is fine (default null) but "exists but wrong type" should warn.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you are asking for "The Shell should validate the schema of all manifests". That's a good idea. We could have done that without TypeScript all these years, but we didn't. A bug in a manifests is like a bug in JavaScript, and should be found and fixed by the developer.

And while TypeScript might benefit from it once schema validation is done (think generating TS types from JSON schemas), I don't think TypeScript by itself is gonna help. All it does it point out our sins...

So, can I ask to keep the manifest schema validation yak out of this PR? :-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, can I ask to keep the manifest schema validation yak out of this PR?

I am fine with that. I am not fine with pretending that everything is good here and papering over the issue with as -- it really isn't, and tsc rightfully complains. So if it's too hard to fix these places, then let's add something greppable and obvious like @ts-expect-error. (Because as does have valid use cases, and isn't greppable)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary from meeting: My gut feeling/slight preference is the get_json_string() kind of accessor on usage, but I agree that for common keys we may rather want some more centralized "schema validation" thing, with a constructor. My minimum requirement here would be to add a // HACK: unsafe "as" or similar so that we can find these again.

pkg/shell/nav.tsx Show resolved Hide resolved
superuser_connection: cockpit.DBusClient | null = null;
superuser: cockpit.DBusProxy | null = null;

handleClickOutside = () => this.setState({ menuOpened: false, docsOpened: false });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining this outside of the ctor at the class level looks weird. What would this even be here? I suppose the this gets resolved at call time even for an arrow, but then wouldn't that be the this of the call environment instead of the TopNav instance?

The tests prove it works -- my eyes just trip over it. JS has too much magic!

if (this.state.osRelease.DOCUMENTATION_URL)
docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
if (this.state.osRelease?.DOCUMENTATION_URL)
docItems.push(<DropdownItem key="os-doc" to={this.state.osRelease.DOCUMENTATION_URL as string} target="blank" rel="noopener noreferrer" icon={<ExternalLinkAltIcon />}>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could tolerate this, as runtime errors here aren't catastrophic. But DOC_URL is optional and can well be undefined (or someone could valalize their os-release to be an object or int). This really shouldn't become a pattern, something like json_get_str() would be much better here.

{cockpit.format(_("$0 documentation"), this.state.osRelease.NAME)}
</DropdownItem>);

const shell_manifest = (cockpit.manifests.shell || {}) as ShellManifest;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same "unvalidated user-provided document" issue as above. We cannot claim that this is a valid ShellManifest. The typechecker will then falsely claim that our code is correct, while it is missing runtime type checks.

Comment on lines +257 to +259
this.setState((prevState: TopNavState) => {
return { docsOpened: !prevState.docsOpened };
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, => ({ key: value }) is a nicer way to return an object in an arrow. Avoids the extra { return }; wrapper.

Comment on lines -105 to +109
const manifest = cockpit.manifests.shell || { };
const manifest = (cockpit.manifests.shell || { }) as ShellManifest;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dito

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants